﻿using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Web;
using System.Xml;
using AmazonBookLinker.Properties;

namespace AmazonBookLinker
{
  public sealed class AmazonService
  {
    private readonly string associateTag;
    private readonly string endPoint;
    private readonly HMAC signer;

    private const string RequestUri = "/onca/xml";
    private const string RequestMethod = "GET";

    private const int RequestTimeout = 20000;

    /// <summary>
    /// Initializes a new instance of the <see cref="AmazonService"/> class.
    /// </summary>
    /// <param name="associateTag">The associate tag.</param>
    /// <param name="locale">The locale.</param>
    public AmazonService( string associateTag, AmazonLocale locale )
    {
      this.associateTag = associateTag;
      this.endPoint = GetEndPointBasedOnLocale( locale );
      this.signer = new HMACSHA256( Encoding.UTF8.GetBytes( Settings.Default.AwsSecretKey ) );
    }

    /// <summary>
    /// Gets the endpoint based on locale.
    /// </summary>
    /// <param name="locale">The locale.</param>
    /// <returns></returns>
    private static string GetEndPointBasedOnLocale( AmazonLocale locale )
    {
      switch ( locale )
      {
        case AmazonLocale.US:
          return "ecs.amazonaws.com";

        case AmazonLocale.CA:
          return "ecs.amazonaws.ca";

        case AmazonLocale.UK:
          return "ecs.amazonaws.co.uk";

        case AmazonLocale.FR:
          return "ecs.amazonaws.fr";

        case AmazonLocale.DE:
          return "ecs.amazonaws.de";

        case AmazonLocale.JP:
          return "ecs.amazonaws.jp";
      }

      return "ecs.amazonaws.com";
    }

    /// <summary>
    /// Signs the specified request.
    /// </summary>
    /// <param name="request">The request.</param>
    /// <returns></returns>
    private string Sign( IDictionary<string, string> request )
    {
      // Use a SortedDictionary to get the parameters in naturual byte order, as required by AWS.
      var pc = new ParamComparer();
      var sortedMap = new SortedDictionary<string, string>( request, pc );

      // Add the AWSAccessKeyId, AssociateTag and Timestamp to the requests.
      sortedMap["AWSAccessKeyId"] = Settings.Default.AwsAccessKeyId;
      sortedMap["AssociateTag"] = this.associateTag;
      sortedMap["Timestamp"] = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"); // NOTE: Current time in IS0 8601 format as required by Amazon.

      // Get the canonical query string
      string canonicalQueryString = ConstructCanonicalQueryString( sortedMap );

      // Derive the bytes needs to be signed.
      var stringBuilder = new StringBuilder();
      stringBuilder.Append( RequestMethod ).Append( "\n" ).Append( this.endPoint ).Append( "\n" ).Append( RequestUri ).Append( "\n" ).Append( canonicalQueryString );

      string stringToSign = stringBuilder.ToString();
      byte[] toSign = Encoding.UTF8.GetBytes( stringToSign );

      // Compute the signature and convert to Base64.
      byte[] sigBytes = this.signer.ComputeHash( toSign );
      string signature = Convert.ToBase64String( sigBytes );

      // Construct the complete URL and return to caller.
      StringBuilder qsBuilder = new StringBuilder();
      qsBuilder.Append( "http://" ).Append( this.endPoint ).Append( RequestUri ).Append( "?" ).Append( canonicalQueryString ).Append( "&Signature=" ).Append( PercentEncodeRfc3986( signature ) );

      return qsBuilder.ToString();
    }

    /// <summary>
    /// Signs a request in the form of a query string.
    /// </summary>
    /// <param name="queryString">The query string.</param>
    /// <returns></returns>
    private string Sign( string queryString )
    {
      var request = CreateDictionary( queryString );
      return Sign( request );
    }


    /// <summary>
    /// Percent-encode (URL Encode) according to RFC 3986 as required by Amazon.
    /// This is necessary because .NET's HttpUtility.UrlEncode does not encode
    /// according to the above standard. Also, .NET returns lower-case encoding
    /// by default and Amazon requires upper-case encoding.
    /// </summary>
    /// <param name="url">The URL.</param>
    /// <returns></returns>
    private static string PercentEncodeRfc3986( string url )
    {
      url = HttpUtility.UrlEncode( url, Encoding.UTF8 );
      if ( url != null )
        url = url.Replace( "'", "%27" ).Replace( "(", "%28" ).Replace( ")", "%29" ).Replace( "*", "%2A" ).Replace( "!", "%21" ).Replace( "%7e", "~" ).Replace( "+", "%20" );

      var stringBuilder = new StringBuilder( url );
      for ( int i = 0; i < stringBuilder.Length; i++ )
      {
        if ( stringBuilder[i] == '%' )
        {
          if ( Char.IsDigit( stringBuilder[i + 1] ) && Char.IsLetter( stringBuilder[i + 2] ) )
            stringBuilder[i + 2] = Char.ToUpper( stringBuilder[i + 2] );
        }
      }
      return stringBuilder.ToString();
    }

    /// <summary>
    /// Convert a query string to corresponding dictionary of name-value pairs.
    /// </summary>
    /// <param name="queryString">The query string.</param>
    /// <returns></returns>
    private static IDictionary<string, string> CreateDictionary( string queryString )
    {
      var map = new Dictionary<string, string>();

      string[] requestParams = queryString.Split( '&' );

      for ( int i = 0; i < requestParams.Length; i++ )
      {
        if ( requestParams[i].Length < 1 )
          continue;

        char[] sep = {
                         '='
                     };
      
        string[] param = requestParams[i].Split( sep, 2 );

        for ( int j = 0; j < param.Length; j++ )
          param[j] = HttpUtility.UrlDecode( param[j], Encoding.UTF8 );

        switch ( param.Length )
        {
          case 1:
          {
            if ( requestParams[i].Length >= 1 )
            {
              if ( requestParams[i].ToCharArray()[0] == '=' )
                map[string.Empty] = param[0];
              else
                map[param[0]] = string.Empty;
            }
            break;
          }
          case 2:
          {
            if ( !string.IsNullOrEmpty( param[0] ) )
              map[param[0]] = param[1];
          }
            break;
        }
      }

      return map;
    }

    /// <summary>
    /// Constructs the canonical query string from the sorted parameter map.
    /// </summary>
    /// <param name="sortedParamMap">The sorted param map.</param>
    /// <returns></returns>
    private static string ConstructCanonicalQueryString( ICollection<KeyValuePair<string, string>> sortedParamMap )
    {
      var stringBuilder = new StringBuilder();

      if ( sortedParamMap.Count == 0 )
      {
        stringBuilder.Append( string.Empty );
        return stringBuilder.ToString();
      }

      foreach ( var kvp in sortedParamMap )
      {
        stringBuilder.Append( PercentEncodeRfc3986( kvp.Key ) );
        stringBuilder.Append( "=" );
        stringBuilder.Append( PercentEncodeRfc3986( kvp.Value ) );
        stringBuilder.Append( "&" );
      }

      string canonicalString = stringBuilder.ToString();
      canonicalString = canonicalString.Substring( 0, canonicalString.Length - 1 );
      return canonicalString;
    }

    /// <summary>
    /// To help the SortedDictionary order the name-value pairs in the correct way.
    /// </summary>
    internal class ParamComparer : IComparer<string>
    {
      public int Compare( string p1, string p2 )
      {
        return string.CompareOrdinal( p1, p2 );
      }
    }

    /// <summary>
    /// Searches the books based on the keywords.
    /// </summary>
    /// <param name="keywords">The keywords.</param>
    /// <param name="itemPage">The item page.</param>
    /// <returns></returns>
    public XmlDocument Search( string keywords, int itemPage )
    {
      if ( string.IsNullOrEmpty( keywords ) )
        return null;

      var searchRequestUrl = Sign(GetRequestQueryString(keywords, itemPage));

      HttpWebRequest request = (HttpWebRequest)WebRequest.Create(searchRequestUrl);
      request.Timeout = RequestTimeout;
      using ( var response = request.GetResponse() )
      {
        using ( var responseStream = response.GetResponseStream() )
        {
          return LoadXmlDocument( new StreamReader( responseStream ) );
        }
      }
    }

    /// <summary>
    /// Loads the XML document.
    /// </summary>
    /// <param name="reader">The reader.</param>
    /// <returns></returns>
    private static XmlDocument LoadXmlDocument( TextReader reader )
    {
      XmlDocument document = new XmlDocument();
      document.LoadXml( reader.ReadToEnd() );
      return document;
    }

    /// <summary>
    /// Gets the request query string.
    /// </summary>
    /// <param name="keywords">The keywords.</param>
    /// <param name="itemPage">The item page.</param>
    /// <returns></returns>
    private string GetRequestQueryString( string keywords, int itemPage )
    {
      return string.Format( "Service=AWSECommerceService&Version=2013-08-01&AWSAccessKeyId={0}&AssociateTag={1}&Operation=ItemSearch&SearchIndex=Books&Keywords={2}&ResponseGroup=Large,Images&ItemPage={3}", Settings.Default.AwsAccessKeyId, this.associateTag, keywords, itemPage );
    }
  }

  public enum AmazonLocale
  {
    US = 0, // Amazon.com
    CA, // Amazon.ca
    UK, // Amazon.co.uk
    DE, // Amazon.de
    FR, // Amazon.fr
    JP, // Amazon.jp
  }
}